筆記目錄

Skip to content

MemoryCache 在 ASP.NET MVC 上的應用

使用 ActionFilter 來快取 Action 內容

OutputCacheAttribute 為 MVC 所提供的一個 ActionFilter,用來將 Action Method 標註要使用快取,如果未特別設定 OutputCacheOutputCacheAttribute 的 Server 端的快取是使用 MemoryCache 來實作。

Properties

  • Duration:快取期間 (秒鐘)。

  • Location: 快取儲存位置,設定值請參考 「OutputCacheLocation」,以下簡略說明:

    • None:停用快取。
    • Client:瀏覽器用戶端。
    • Server:Web Server。
    • Downstream:Client 和 Proxy Server。
    • ServerAndClient:Client 和 Web Server。
    • Any:Web Server、Client 和 Proxy Server。
  • NoStore:設定是否不允許快取。

  • VaryByXXX:依Header、Form 和 Query 參數等來區分快取內容,例如報表查詢時,應該要針對不同的查詢條件設定快取。

  • CacheProfile:設定 Config 定義的快取方案的 Name,通常專案上會有幾個固定的快取方案,為避免當方案內容異動時,要修改全部使用該方案的程式碼,所以會在 Config 定義各方案的快取設定,各 Action Method 再使用 CacheProfile 來指定快取方案。

TIP

NoStore 和 Location.None 看起來很類似,但實際作用不一樣,具體行為如下:

  • NoStore:將 Header 的 Cache-Control 設為 no-store,不影響 Web Server 的快取。
  • Location.None:將 Header 的 Cache-Control 設為 no-cache,且不儲存 Web Server 的快取。

Cache-Control 的行為可參考「Cache-Control」,節錄 no-storeno-cache 的內容如下: no-store: 不要讓瀏覽器快取。 no-cache:使用快取,但每次請求前都先向伺服器檢查是否有新的內容。

有關各項 Location 設定值的執行結果,可以參考黑暗執行緒的這篇文章「ASP.NET OutputCache 快取行為深入觀察」。

具體的使用案例

當有純資料查詢的功能頁面(不能含可連至編輯頁面的連結,曾遇過快取只有 30 秒,結果客戶很快速的到編輯頁修改資料返回,然後疑惑資料怎麼沒有變),如果有些資料查詢比較花時間,就可使用 OutputCacheAttribute 來建立快取資料。

有關查詢功能的快取,可以使用 VaryByParam="*" 來針對不同的 QueryString 或 POST 的參數來建立不同的快取版本,但如果是有針對不同的使用者設定權限的話,會導致不同權限的使用者快取到相同的結果,所以需要在額外針對此進行處理,以下是實作範例。

Web.config

duration 請依專案狀況設定秒數。

xml
<system.web>
  <caching>
    <outputCacheSettings>
      <outputCacheProfiles>
        <add name="Default" duration="30" varyByParam="*" varyByCustom="Cookie" noStore="true" />
      </outputCacheProfiles>
    </outputCacheSettings>
  </caching>
</system.web>

Global.asax.cs

利用 Override GetVaryByCustomString(),來針對不同使用者產生不同的 Key,以下提供使用 Cookie 和 Session 兩種方式,如果是使用 Session,須將「Web.config」的 varyByCustom 的值 改為 Session

csharp
public class MvcApplication : HttpApplication {
    public override string GetVaryByCustomString(HttpContext context, string custom) {
        const string OutputCacheKey = "OutputCacheId";

        if (custom.Equals("Cookie", StringComparison.OrdinalIgnoreCase)) {
            if (Request.Cookies[OutputCacheKey] == null) {
                string cacheId = Guid.NewGuid().ToString();
                Response.Cookies.Add(new HttpCookie(OutputCacheKey) {
                    Value = cacheId,
                    HttpOnly = true,
                    Expires = DateTime.Now.AddHours(1),
                    Secure = false // 請依是否有使用 SSL 設定
                });

                return cacheId;
            }

            return Request.Cookies[OutputCacheKey].Value;
        }

        // UserId 請替換成實際登入帳號的 Session Key
        if (custom.Equals("Session", StringComparison.OrdinalIgnoreCase)
            && Session["UserId"] != null
        ) {
            string userId = Session["UserId"].ToString();

            if (Session[OutputCacheKey] == null
                || !(Session[OutputCacheKey] is VaryByCustomInfo customInfo)
                || customInfo.UserId == userId
            ) {
                Guid value = Guid.NewGuid();
                Session[OutputCacheKey] = new VaryByCustomInfo(userId, value);
                return value.ToString();
            }

            return customInfo.Value.ToString();
        }

        return base.GetVaryByCustomString(context, custom);
    }

    private class VaryByCustomInfo {
        public VaryByCustomInfo(string userId, Guid value) {
            UserId = userId ?? throw new ArgumentNullException(nameof(userId));
        }

        public string UserId { get; }

        public Guid Value { get; }
    }
}

Controller

可以在 Index 裡面設定中斷點測試,可以發現只要 model 內容相同,且同個人瀏覽頁面,那麼一定時間內,只有第一次瀏覽會觸發中斷點。

csharp
public class TestController : Controller {
    [OutputCache(CacheProfile = "Default")]
    [HttPost]
    public ActionResult Index(IndexViewModel model) {
        //...實作使用 model 產生 ActionResult 回傳...
    }
}

更新資料庫時,清除快取資料

有些資料量不大且不常異動的資料,可以存放到快取,減少與資料庫連線次數,例如:縣市資料和資料庫的網站設定等。 為避免異動資料時,資料快取仍是舊的,必須要在異動資料的在 API 裡,清除快取或更新快取資料,但如果遇到去改資料庫資料的情況,仍會有快取到舊資料的可能,所以最好的作法是直接監聽資料庫資料,當資料變更時,清空快取資料。

ChangeMonitor

MemoryCache 可使用 ChangeMonitor 來偵測資料來源是否變更,.NET Framework 提供了以下兩個實作 Class:

  • HostFileChangeMonitor:用來監測主機上的檔案異動。
  • SqlChangeMonitor:用來偵測 SQL Server 上的資料異動。 SqlChangeMonitor 使用「SqlDependency」來監測資料庫的資料異動,當 SqlDependency 加入 SqlCommand 時,會建立一個 「SqlNotificationRequest」 指派給 SqlCommand 來與 SQL Server 建立通知要求,而當資料進行異動時,SqlChangeMonitor 就會通知 MemoryCache 清除快取資料。

範例

Global.asax.cs

csharp
public class MvcApplication : System.Web.HttpApplication {
    protected void Application_Start() {
        //...其他在 Application_Start 的實作...

       // 增加此行
       SqlDependency.Start(WebConfigurationManager.ConnectionStrings["MyDB"].ConnectionString);
    }

    protected void Application_End() {
        // 增加此行
        SqlDependency.Stop(WebConfigurationManager.ConnectionStrings["MyDB"].ConnectionString);
    }
}

HomeController

csharp
public class HomeController : Controller {
    private static DateTime lastChangedTime;

    private const string CacheKey = "CacheKey";

    public ActionResult TestDependency() {
        // 判斷沒 Cache 資料,就建立
        if (MemoryCache.Default[CacheKey] is null) {
            CreateCache();
        }
        ViewBag.Key1 = MemoryCache.Default[CacheKey] as string;
        ViewBag.LastChangedTime = lastChangedTime;

        return View();
    }

    private void CreateCache() {
        string connectionStr = WebConfigurationManager.ConnectionStrings["MyDB"].ConnectionString;

        CacheItemPolicy policy = new CacheItemPolicy();
        using (SqlConnection conn = new SqlConnection(connectionStr))
        using (SqlCommand cmd = new SqlCommand("SELECT Key1 FROM dbo.Config", conn)) {
            SqlDependency dependency = new SqlDependency(cmd);
            // 可用 OnChange 來在資料異動時,更新其他資料
            dependency.OnChange += SqlDependencyOnChange;

            conn.Open();
            // 需要執行一次 SQL Command,監聽才可以生效,可以視情況看是否順便取得資料
            string key1 = cmd.ExecuteScalar().ToString();

            SqlChangeMonitor monitor = new SqlChangeMonitor(dependency);
            policy.ChangeMonitors.Add(monitor);

            // 設定快取資料,當資料異動時,清除快取資料
            MemoryCache.Default.Set(CacheKey, key1, policy);
        }
    }

    private void SqlDependencyOnChange(object sender, SqlNotificationEventArgs e) {
        lastChangedTime = DateTime.Now;
        (sender as SqlDependency).OnChange -= SqlDependencyOnChange;
    }
}

WARNING

  • 為啟用資料監聽,需要在資料庫中啟用 Service Broker 功能。
  • 用來監聽資料的 SQL 語法,必須要指定到具體要監聽的欄位,且資料表名稱必須要涵蓋 Schema (靘? dbo),否則無法正確建立快取資料。
  • SqlDependency 設定 SqlCommand 後,必需執行一次 SqlCommand 才可生效。

啟用 Service Broker

如果尚未啟用 Service Broker,可以使用以下語法啟用:

sql
ALTER DATABASE {資料庫名稱} SET ENABLE_BROKER;

如果將已經啟用了 Service Broker 的資料庫卸載再重新掛載,執行此語法,則可能會遇到以下錯誤訊息:

text
無法在資料庫 "<DBName>" 中啟用 Service Broker,因為資料庫 (<GUID>) 中的 Service Broker GUID 與 sys.databases (<GUID>) 中的不相符。

此時,需要使用以下語法重新設定 Service Broker:

sql
ALTER DATABASE {資料庫名稱} SET NEW_BROKER;

由於啟用 Service Broker 必須要在其他使用者連線的情況下進行,因此如果是運行中的資料庫,需要在執行上述語法時加上 WITH ROLLBACK IMMEDIATE,以回復未完成的交易並中斷其他使用者對資料庫的連線。因此,完整的語法如下所示:

sql
ALTER DATABASE {資料庫名稱} SET NEW_BROKER WITH ROLLBACK IMMEDIATE;

異動歷程

  • 2022-11-14 初版文件建立。